// This file is part of SyncStar, free software released under GPL v2.
package com.syncstar;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.app.AlarmManager;
import android.app.IntentService;
import android.app.PendingIntent;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.database.sqlite.SQLiteDatabase;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.os.Handler;
import android.util.Log;
import android.widget.Toast;
import com.textuality.aerc.AppEngineClient;
import com.textuality.aerc.Response;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.net.MalformedURLException;
import java.net.URL;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.zip.DataFormatException;
import java.util.zip.Deflater;
import java.util.zip.Inflater;

@SuppressWarnings({"JavaDoc"})
public class SyncService extends IntentService {
  private static final String TAG = SyncService.class.getSimpleName();

  static final String PROPAGATE_TO_ACTIVITY =
      GlobalReceiver.class.getName() + ".PROPAGATE_TO_ACTIVITY";

  static final int DEFAULT_SYNC_INTERVAL_MS = 3600000;  // 1 hour.
  static final int MIN_SYNC_INTERVAL_MS = 10000;  // This drains the battery.

  // TODO: Can we reuse the same request code for different intents?
  static final int REQUEST_CODE_SYNC = 42;
  static final int REQUEST_CODE_PROPAGATE = 43;
  static final int REQUEST_CODE_POPULATE = 44;

  static final String DEFAULT_SYNC_URL = "https://syncstarweb.appspot.com/sync";
  // static final URL DEFAULT_SYNC_URL;
  // static {
  //   try {
  //     DEFAULT_SYNC_URL = new URL("https://syncstarweb.appspot.com/sync");
  //   } catch (MalformedURLException e) {
  //     throw new RuntimeException("URL", e);
  //   }
  // }

  static final Charset CHARSET_UTF8 = Charset.forName("UTF-8");

  Handler handler_;

  public SyncService() {
    super("FooSyncService");  // This name doesn't matter.
  }

  @Override
  public void onCreate() {
    super.onCreate();
    handler_ = new Handler();
  }

  /** Needed to show a toast in a Service.
   * Creating the toast outside the Handler doesn't work either.
   * See more at
   * http://stackoverflow.com/questions/3296639/toast-created-in-an-intentservice-never-goes-away
   */
  @SuppressWarnings({"UnusedDeclaration"})
  void showToast(final String msg, final int duration) {
    handler_.post(new Runnable() {
      public void run() {
        Toast.makeText(SyncService.this, msg, duration).show();
      }
    });
  }

  static final int RUN_NOT_RUNNING = 0;
  static final int RUN_MANUAL = 1;
  static final int RUN_SCHEDULED = 2;
  /** Non-scheduled background run. */
  static final int RUN_BACKGROUND = 3;

  /** Protects run_. */
  static Lock runLock_ = new ReentrantLock();
  /** One of run_, protected by runLock_. */
  static int run_ = RUN_NOT_RUNNING;

  /** Held by doSync() to prevent a scheduled and a manual sync from running
   * at the same time.
   */
  static Lock syncLock_ = new ReentrantLock();

  static int getRun() {
    runLock_.lock();
    try {
      return run_;
    } finally {
      runLock_.unlock();
    }
  }

  static void setRun(int run) {
    runLock_.lock();
    try {
      run_ = run;
    } finally {
      runLock_.unlock();
    }
  }

  @Override
  protected void onHandleIntent(Intent intent) {
    int run = intent.getExtras().getInt("run", RUN_MANUAL);
    String email = intent.getExtras().getString("email");
    doSync(this, run, email);
  }

  static String escape(String s) {
    // return (new JSONStringer()).value(s).toString();
    // TODO: Don't show the brackets.
    return (new JSONArray()).put(s).toString();
  }

  /** Helper class to hold the tuple (address, body, date, type).
   * Can be added to a HashSet.
   */
  static class MessageKey {
    String address_;
    String body_;
    long date_;
    int type_;

    MessageKey(String address, String body, long date, int type) {
      if (address == null)
        throw new RuntimeException("MessageKey(address= is null.");
      if (body == null)
        throw new RuntimeException("MessageKey(body= is null.");
      address_ = address;
      body_ = body;
      date_ = date;
      type_ = type;
    }

    @Override
    public boolean equals(Object o) {
      if (this == o)
        return true;
      if (!(o instanceof MessageKey))
        return false;
      MessageKey other = (MessageKey)o;
      return address_.equals(other.address_) && body_.equals(other.body_) &&
             date_ == other.date_ && type_ == other.type_;
    }
    @Override
    public int hashCode() {
      int result = 17;
      result = 31 * result + (int)(date_ ^ (date_ >>> 32));
      result = 31 * result + type_;
      result = 31 * result + address_.hashCode();
      result = 31 * result + body_.hashCode();
      return result;
    }

    static final String WHERE_CLAUSE =
        "address=? AND body=? AND date=? AND type=?";
    String[] getWhereArgs() {
      return new String[] { address_, body_, date_ + "", type_ + "" };
    }

    @Override
    public String toString() {
      JSONObject o = new JSONObject();
      try {
        o.put("address", address_);
        o.put("body", body_);
        o.put("date", date_);
        o.put("type", type_);
      } catch (JSONException e) {
        throw new RuntimeException("MessageKey.toString", e);
      }
      return "MessageKey" + o;
    }
  }

  static class MessageValue {
    int read_;
    int seen_;
    long threadId_;
    boolean isAddressEmpty_;

    MessageValue(int read, int seen, long threadId, boolean isAddressEmpty) {
      read_ = read;
      seen_ = seen;
      threadId_ = threadId;
      isAddressEmpty_ = isAddressEmpty;
    }
  }

  static String getSyncUrl(SharedPreferences spConfig) {
    return spConfig.getString("sync_url", DEFAULT_SYNC_URL);
  }

  static URL getSyncUrl(Context context) {
    final String syncUrlStr = getSyncUrl(context.getSharedPreferences("config", 0));
    try {
      return new URL(syncUrlStr);
    } catch (MalformedURLException e) {
      try {
        return new URL(DEFAULT_SYNC_URL);
      } catch (MalformedURLException ee) {
        throw new RuntimeException("default sync URL", ee);
      }
    }
  }

  static URL getRootUrlFromSyncUrl(URL syncUrl) {
    try {
      return new URL(syncUrl, "/");
    } catch (MalformedURLException e) {
      throw new RuntimeException("sync URL", e);
    }
  }

  static final boolean DO_COMPRESS_SYNC = true;

  /** Does the sync in the current thread in a blocking way.
   * @returns A STATUS_ value.
   */
  @SuppressWarnings({"ConstantConditions"})
  static int doSync(Context context, int run, String email) {
    // TODO: Find a better way than a static boolean.
    syncLock_.lock();
    setRun(run);
    Log.e(TAG, "CONNX doSync call run=" + run + " email=" + email);
    context.sendBroadcast(new Intent(PROPAGATE_TO_ACTIVITY));
    try {
      int status = STATUS_ALREADY;
      String error = null;
      int httpStatus = -2;
      try {
        // Populate unconditionally, because the `if' in GlobalReceiver depends
        // on populatePresenceTable() eventually getting called.
        Log.e(TAG, "CONNX doSync populatePresenceTable");
        SmsUtils.populatePresenceTable(context, -1);  // Always populate.

        SharedPreferences spSync = context.getSharedPreferences("sync", 0);
        int count = spSync.getInt("count", 0);
        spSync.edit().putInt("count", count + 1).commit();
        boolean isConnectedNow = isConnected(context);
        if (email == null)
          email = "";
        Account account = email.length() == 0 ? null : getAccount(context, email);
        boolean isKnown = account != null;
        Log.e(TAG, "CONNX syncing count=" + count +
              " isConnected=" + isConnectedNow +
              " run=" + run +
              " email=" + email +
              " isKnown=" + isKnown);
        if (!isKnown) {
          status = STATUS_ACCOUNT_UNKNOWN;
        } else if (!isConnectedNow) {
          status = STATUS_NOT_CONNECTED;
        } else {
          URL syncUrl = getSyncUrl(context);
          URL rootUrl = getRootUrlFromSyncUrl(syncUrl);
          AppEngineClient appEngineClient = new AppEngineClient(
              rootUrl, account, context);
          final int messageCount[] = new int[] { 0 };
          final Map<MessageKey, MessageValue> messageKeys = new HashMap<MessageKey, MessageValue>();
          JSONObject phoneSmsData = SmsUtils.getPhoneSmsData(context, new SmsUtils.ShortSmsDataReceiver() {
            public void receiveShortSmsData(
                String address, String body, long date, int type,
                int read, int seen,
                long threadId, boolean isAddressEmpty) {
              ++messageCount[0];
              messageKeys.put(new MessageKey(address, body, date, type),
                  new MessageValue(read, seen, threadId, isAddressEmpty));
            }
          });
          JSONArray messages;
          try {
            messages = phoneSmsData.getJSONArray("messages");
          } catch (JSONException e) {
            throw new RuntimeException("messages", e);
          }
          final JSONArray messages2 = messages;
          final int deletedCount[] = new int[] { 0 };
          final List<MessageKey> vanishingMessageKeys = new ArrayList<MessageKey>();
          // There is no need for an SQLite transaction here.
          SmsUtils.iterPresenceTable(context, new SmsUtils.PresenceEntryReceiver() {
            public void receivePresenceEntry(String address, String body, long date, int type) {
              MessageKey messageKey = new MessageKey(address, body, date, type);
              if (!messageKeys.containsKey(messageKey)) {
                vanishingMessageKeys.add(messageKey);
                try {
                  JSONObject message = new JSONObject();
                  message.put("address", address);
                  message.put("body", body);
                  message.put("date", date);
                  message.put("type", type);
                  message.put("deleted", true);
                  messages2.put(message);
                  ++deletedCount[0];
                } catch (JSONException e) {
                  throw new RuntimeException("deleted message", e);
                }
              }
            }
          });
          byte[] postData = convertByteBufferToByteArray(CHARSET_UTF8.encode(phoneSmsData.toString()));
          int postDataUncompressedSize = postData.length;
          //logLongString("CONNX postData " + new String(postData));
          if (DO_COMPRESS_SYNC)
            postData = deflate(postData, Deflater.BEST_COMPRESSION);
          // Example sizes: messageCount=324 deletedCount=5
          // postData.uncompressedSize=101710 postData.compressedSize=18256
          Log.e(TAG, "CONNX doSync messageCount=" + messageCount[0] +
                     " deletedCount=" + deletedCount[0] +
                     " postData.uncompressedSize=" + postDataUncompressedSize +
                     " postData.compressedSize=" + postData.length);
          final Map<String,List<String>> headers = new HashMap<String,List<String>>() {{
            put("Content-Type", new ArrayList<String>() {{ add("application/json; charset=UTF-8"); }});
            if (DO_COMPRESS_SYNC)
              put("Content-Encoding", new ArrayList<String>() {{ add("deflate"); }});
          }};
          Response response = appEngineClient.post(syncUrl, headers, postData);
          if (response != null)
            httpStatus = response.status;
          if (response == null) {
            status = STATUS_RESPONSE_ERROR;
            error = appEngineClient.errorMessage();
            // TODO: This is a 16-line error with a stack trace. Strip it.
            Log.e(TAG, "CONNX Error in AppEngineClient:\n" + error);
          } else if (response.status != 200) {
            if (response.status == 403) {
              // TODO: Test what really happens if the supplied Google account is wrong.
              status = STATUS_HTTP_PERMISSION;
            } else if (response.status == -10) {
              status = STATUS_BAD_SSL_CERTIFICATE;
            } else if (response.status == 400) {
              status = STATUS_HTTP_BAD_REQUEST;
            } else if (response.status == 500) {
              status = STATUS_HTTP_SERVER_ERROR;
            } else {
              status = STATUS_HTTP_ERROR;
            }
            // TODO: Detect encoding in Content-Type and use it.
            if (response.body == null) {  // Usually it's true.
              Log.e(TAG, "CONNX HTTP error status=" + httpStatus);
            } else {
              Log.e(TAG, "CONNX HTTP error status=" + httpStatus + " body:\n" +
                    escape(CHARSET_UTF8.decode(ByteBuffer.wrap(response.body)).toString()));
            }
          } else {
            // TODO: Detect encoding in Content-Type and use it.
            //Log.e(TAG, "CONNX HTTP OK body:\n" +
            //      escape(new String(response.body)));

            byte[] responseBody = response.body;
            Log.e(TAG, "CONNX HTTP response bodysize=" + responseBody.length +
                  " headers: " + response.headers);
            // We must specify the header names in all lower case for Android
            // 2.2 (FROYO.XWJML). It works either way in 2.3 and onwards.
            if ((response.headers.get("content-encoding") != null &&
                 response.headers.get("content-encoding").size() == 1 &&
                 response.headers.get("content-encoding").get(0).toLowerCase()
                 .equals("deflate")) ||
                (response.headers.get("x-content-encoding") != null &&
                 response.headers.get("x-content-encoding").size() == 1 &&
                 response.headers.get("x-content-encoding").get(0).toLowerCase()
                 .equals("deflate"))) {
              Log.e(TAG, "CONNX HTTP response compressedsize=" + responseBody.length);
              // Log.e(TAG, "CONNX HTTP response compressed: " + hexdump(responseBody));
              responseBody = inflate(responseBody);
              Log.e(TAG, "CONNX HTTP OK body JSON uncompressedSize=" + responseBody.length);
            }
            // This needs too much memory (>128MB) on the phone for 16000 messages.
            // TODO: make it use less memory.
            String body = CHARSET_UTF8.decode(ByteBuffer.wrap(responseBody)).toString();
            // Log.e(TAG, "CONNX HTTP OK body string: " + escape(body));  // Might be too long.
            JSONObject jsonObject = null;
            try {
              // SUXX: This succeeds if there is trailing garbage.
              // At least it fails if the array is not closed properly.
              jsonObject = new JSONObject(body);
              // TODO: Check for EOF in in the JSONTokener.
            } catch (JSONException e) {
              status = STATUS_JSON_PARSE_ERROR;
            }
            if (status == STATUS_JSON_PARSE_ERROR) {
              Log.e(TAG, "CONNX HTTP body JSON parse error for: " +
                    hexdump(responseBody, 256));
            } else {
              status = processSyncResponse(context, messageKeys,
                                           vanishingMessageKeys, jsonObject);
            }
          }
        }
        Log.e(TAG, "CONNX sync status=" + status +
              " httpStatus=" + httpStatus);
        // showToast("Sync toast " + isConnectedNow + " " + wasScheduled + " #" + count, Toast.LENGTH_SHORT);
        // TODO: Send a message to the activity if it's active.
      } finally {
        SharedPreferences.Editor spLastsyncEdit =
            context.getSharedPreferences("lastsync", 0).edit();
        long now = System.currentTimeMillis();
        //noinspection ConstantConditions
        if (httpStatus >= 0) {
          spLastsyncEdit.putLong("last_with_response", now);
        } else {
          spLastsyncEdit.putLong("last_without_response", now);
        }
        if (run != RUN_MANUAL)
          spLastsyncEdit.putLong("last_non_manual", now);
        spLastsyncEdit
            .putInt("run", run)
            .putBoolean("happened_since_boot", true)
            .putString("error", error)
            .putInt("status", status);
        spLastsyncEdit.commit();
        // TODO: Set this even in a GET /sync request.
        if (status == STATUS_SYNC_OK)
          context.getSharedPreferences("config", 0).edit().putBoolean("is_verified", true).commit();
      }
      // TODO: Update the Android UI after some operations. See
      //       private void updateAllThreads(final boolean async);
      //       in sms-backup-plus for a workaround.
      return status;
    } finally {
      setRun(RUN_NOT_RUNNING);
      Log.e(TAG, "CONNX doSync return run=" + getRun());
      context.sendBroadcast(new Intent(PROPAGATE_TO_ACTIVITY));
      syncLock_.unlock();
    }
  }

  static String hextable = "0123456789abcdef";

  static String hexdump(byte[] data, int maxSize) {
    if (data.length < maxSize)
      maxSize = data.length;
    char hex[] = new char[2 * maxSize];
    for (int i = 0; i < maxSize; ++i) {
      hex[2 * i] = hextable.charAt((data[i] >> 4) & 15);
      hex[2 * i + 1] = hextable.charAt(data[i] & 15);
    }
    return new String(hex);
  }

  static int processSyncResponse(
      Context context,
      final Map<MessageKey, MessageValue> messageKeys,
      final List<MessageKey> vanishingMessageKeys,
      JSONObject jsonObject) {
    ArrayList<ContentValues> cmessages;
    try {
      if (!jsonObject.has("smessages") ||
          !(jsonObject.get("smessages") instanceof JSONArray)) {
        Log.e(TAG, "CONNX HTTP body JSON bad smessages");
        return STATUS_JSON_BAD_RESPONSE;
      } else {
        JSONArray smessages = jsonObject.getJSONArray("smessages");
        cmessages = new ArrayList<ContentValues>();
        cmessages.ensureCapacity(smessages.length());
        for (int i = 0; i < smessages.length(); ++i) {
          JSONObject smessage = smessages.getJSONObject(i);
          // The web application doesn't include ,address:"" if the address
          // is empty, and we want to accept that, so we accept the response
          // without an address.
          ContentValues contentValues =
              SmsUtils.convertJsonMessageToContentValues(
                  smessage, /*areKeysRequired:*/false);
          if (contentValues == null) {
            Log.e(TAG, "CONNX bad JSON smessage i=" + i +
                  " smessage=" + smessage);
            return STATUS_JSON_BAD_RESPONSE;
          }
          cmessages.add(contentValues);
        }
      }
    } catch (JSONException e) {
      throw new RuntimeException("response JSON navigate", e);
    }
    Log.e(TAG, "CONNX sync updating, cmessages.size=" + cmessages.size());
    // It would be awesome if we could do all ContentResolver updates
    // within a transaction -- but that's not possible in the Android
    // API. So if the user is adding or deleting messages while this
    // synchronization is running, we may not synchronize those added
    // and deleted messages right now (but we'll do next time).

    // Maps thread IDs to the number of messages in that thread.
    final Map<Long, Integer> threadCounts = new HashMap<Long, Integer>();

    final List<MessageKey> newMessageKeys = new ArrayList<MessageKey>();
    final List<MessageKey> deletedMessageKeys = new ArrayList<MessageKey>();
    boolean wasModified = false;
    for (ContentValues cmessage : cmessages) {
      //noinspection UnnecessaryUnboxing
      MessageKey messageKey = new MessageKey(
          cmessage.getAsString("address"),
          cmessage.getAsString("body"),
          cmessage.getAsLong("date").longValue(),
          cmessage.getAsInteger("type").intValue());
      MessageValue messageValue = messageKeys.get(messageKey);
      //noinspection UnnecessaryUnboxing
      if (cmessage.getAsBoolean("deleted").booleanValue()) {
        if (messageValue != null) {
          int numDeleted;
          deletedMessageKeys.add(messageKey);
          // TODO: Delete conversation (thread_id) if last message in conversation.
          // content://mms-sms raises an exception.
          if (messageValue.isAddressEmpty_) {  // For new drafts created on the phone.
            numDeleted = context.getContentResolver().delete(
                SmsUtils.SMS_PROVIDER,
                "body=? AND date=? AND type=? AND thread_id=? AND (address IS NULL OR address=?)",
                new String[] { messageKey.body_, messageKey.date_ + "", messageKey.type_ + "", messageValue.threadId_ + "", "" });
          } else {
            numDeleted = context.getContentResolver().delete(
                SmsUtils.SMS_PROVIDER,
                MessageKey.WHERE_CLAUSE, messageKey.getWhereArgs());
          }
          if (numDeleted > 0) {
            wasModified = true;
            Integer count = threadCounts.get(messageValue.threadId_);
            // Log.e(TAG, "CONNX threadCountDecrease threadId=" + messageValue.threadId_ + " count=" + count);
            threadCounts.put(
                messageValue.threadId_, count == null ? -1 : count - 1);
          }
        }
        // Log.e(TAG, "CONNX delete key=" + messageKey + " numDeleted=" + numDeleted);
      } else if (messageValue != null) {
        @SuppressWarnings({"UnnecessaryUnboxing"})
        int read = cmessage.getAsInteger("read").intValue();
        @SuppressWarnings({"UnnecessaryUnboxing"})
        int seen = cmessage.getAsInteger("seen").intValue();
        if (messageValue.read_ < read || messageValue.seen_ < seen) {
          ContentValues updateValues = new ContentValues();
          updateValues.put("read", read);
          updateValues.put("seen", seen);
          // TODO: Use isAddressEmpty_ for drafts created on the phone?
          int numUpdated = context.getContentResolver().update(
              SmsUtils.SMS_PROVIDER, updateValues,
              MessageKey.WHERE_CLAUSE,
              messageKey.getWhereArgs());
          if (numUpdated > 0)
            wasModified = true;
        }
      } else {
        cmessage.remove("deleted");
        SmsUtils.convertConversationToThreadIdInContentValues(context, cmessage);
        context.getContentResolver().insert(SmsUtils.SMS_PROVIDER, cmessage);
        newMessageKeys.add(messageKey);
        wasModified = true;
      }
    }

    // Notify other applications about our updates.
    if (wasModified) {
      //noinspection ConstantIfStatement
      if (false) {
        // This seems to be superfluous, Android seems to delete conversations
        // automatically.

        // Delete now-emptied conversations.
        // TODO: Delete it safer, new messages might have just arrived to these
        // conversations. A transaction would be very useful here.
        for (MessageValue messageValue : messageKeys.values()) {
          Integer count = threadCounts.get(messageValue.threadId_);
          if (count != null) {  // Deleted some messages from this thread.
            // Log.e(TAG, "CONNX threadCountIncrease threadId=" + messageValue.threadId_ + " count=" + count);
            threadCounts.put(messageValue.threadId_, count + 1);
          }
        }
        for (Map.Entry<Long, Integer> entry : threadCounts.entrySet()) {
          if (entry.getValue() <= 0) {  // All messages in thread deleted.
            long threadId = entry.getKey();
            int numDeleted = context.getContentResolver().delete(
                 Uri.parse("content://sms/conversations/" + threadId), null, null);
            Log.e(TAG, "CONNX delete threadId=" + threadId +
                  " numDeleted=" + numDeleted);
          }
        }
      }

      Log.e(TAG, "CONNX notifyothers");
      context.getContentResolver().delete(Uri.parse("content://sms/conversations/-1"), null, null);
    }

    // Update the presence table:
    //
    // * Insert messages which we have inserted to the
    //   ContentResolver above. (Others were inserted in this
    //   this sync by populatePresenceTable().)
    // * Delete messages which are missing from the phone, i.e. what
    //   we have just deleted above, and those which the user has
    //   deleted (all those in vanishingMessageKeys).
    SmsUtils.modifyPresenceTable(context, true, new SmsUtils.PresenceTableModifier() {
      public void modify(SQLiteDatabase db) {
        for (MessageKey messageKey : newMessageKeys) {
          ContentValues insertValues = new ContentValues();
          insertValues.put("address", messageKey.address_);
          insertValues.put("body", messageKey.body_);
          insertValues.put("date", messageKey.date_);
          insertValues.put("type", messageKey.type_);
          db.insert(SmsUtils.PresenceOpenHelper.PRESENCE_TABLE_NAME,
                    null, insertValues);
        }
        for (MessageKey messageKey : deletedMessageKeys) {
          db.delete(SmsUtils.PresenceOpenHelper.PRESENCE_TABLE_NAME,
                    MessageKey.WHERE_CLAUSE, messageKey.getWhereArgs());
        }
        for (MessageKey messageKey : vanishingMessageKeys) {
          db.delete(SmsUtils.PresenceOpenHelper.PRESENCE_TABLE_NAME,
                    MessageKey.WHERE_CLAUSE, messageKey.getWhereArgs());
        }
      }
    });
    return STATUS_SYNC_OK;
  }

  static byte[] convertByteBufferToByteArray(ByteBuffer byteBuffer) {
    byte[] byteArray = new byte[byteBuffer.limit()];
    System.arraycopy(byteBuffer.array(), 0, byteArray, 0, byteBuffer.limit());
    return byteArray;
  }

  static byte[] deflate(byte[] data, int compression) {
    // Yes, the Deflater Java API for byte[] -> byte[] is so simple. Sigh.
    Deflater deflater = new Deflater(compression);
    deflater.setInput(data);
    deflater.finish();
    // Try to allocate a large enough buffer so reallocation won't be needed.
    // TODO: Run some experiments about the average compression ration, maybe
    //       it's enough to allocate a smaller buffer most of the time.
    byte[] output = new byte[128 + data.length + (data.length >> 6)];
    int deflated = deflater.deflate(output);
    outer_loop: while (!deflater.finished()) {
      while (deflated < output.length) {
        int deflated2 = deflater.deflate(output, deflated, output.length - deflated);
        if (deflater.finished())
          return output;
        if (deflated2 == 0)
          break outer_loop;
        deflated += deflated2;
      }
      byte[] output2 = new byte[output.length * 2];
      System.arraycopy(output, 0, output2, 0, output.length);
      output = output2;
    }
    if (output.length != deflated) {
      byte[] output2 = new byte[deflated];
      System.arraycopy(output, 0, output2, 0, deflated);
      output = output2;
    }
    return output;
  }

  static byte[] inflate(byte[] data) {
    // Yes, the Inflater Java API for byte[] -> byte[] is so simple. Sigh.
    Inflater inflater = new Inflater();
    inflater.setInput(data);
    // Try to allocate a large enough buffer so reallocation won't be needed.
    // TODO: Run some experiments about the average compression ration, maybe
    //       it's enough to allocate a smaller buffer most of the time.
    byte[] output = new byte[data.length < 65536 ? data.length : data.length * 2];
    int inflated;
    try {
      inflated = inflater.inflate(output);
      outer_loop: while (!inflater.finished()) {
        while (inflated < output.length) {
          int inflated2 = inflater.inflate(output, inflated, output.length - inflated);
          if (inflater.finished())
            return output;
          if (inflated2 == 0)
            break outer_loop;
          inflated += inflated2;
        }
        byte[] output2 = new byte[output.length * 2];
        System.arraycopy(output, 0, output2, 0, output.length);
        output = output2;
      }
    } catch (DataFormatException e) {
      throw new RuntimeException("inflate", e);
    }
    if (output.length != inflated) {
      byte[] output2 = new byte[inflated];
      System.arraycopy(output, 0, output2, 0, inflated);
      output = output2;
    }
    return output;
  }

  static void maybeStartOrStopBackgroundScheduled(Context context) {
    // TODO: Call this after each config change.
    // TODO: Probably force an immediate sync after some config changes.
    ConnectivityManager connectivityManager =
        (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE);
    NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
    boolean isConnected = activeNetworkInfo != null && activeNetworkInfo.isConnected();
    boolean isBackgroundDataOk = connectivityManager.getBackgroundDataSetting();
    Intent scheduledIntent = new Intent(context, SyncService.class);
    PendingIntent pendingIntent = PendingIntent.getService(
        context, REQUEST_CODE_SYNC, scheduledIntent, PendingIntent.FLAG_NO_CREATE);
    boolean isScheduledNow = pendingIntent != null;
    SharedPreferences spConfig = context.getSharedPreferences("config", 0);
    boolean isBgEnabled = spConfig.getBoolean("is_bg_enabled", false);
    boolean isVerified = spConfig.getBoolean("is_verified", false);
    String email = spConfig.getString("email", "");
    if (email == null)
      email = "";
    Account account = email.length() == 0 ? null : getAccount(context, email);
    boolean isKnown = account != null;
    int interval = Math.max(
        spConfig.getInt("sync_interval_ms", DEFAULT_SYNC_INTERVAL_MS),
        MIN_SYNC_INTERVAL_MS);
    SharedPreferences spLastsync =
        context.getSharedPreferences("lastsync", 0);
    long lastNonManualSyncAt = spLastsync.getLong("last_non_manual", Long.MIN_VALUE);
    long lastSyncWithResponseAt = spLastsync.getLong("last_with_response", Long.MAX_VALUE);
    long now = System.currentTimeMillis();
    long noSyncWithResponseFor =
        (lastSyncWithResponseAt == Long.MAX_VALUE) ? Long.MAX_VALUE
        : now - lastSyncWithResponseAt;
    long noNonManualSyncFor =
        (lastNonManualSyncAt == Long.MAX_VALUE) ? Long.MAX_VALUE
        : now - lastNonManualSyncAt;
    boolean happenedSinceBoot = spLastsync.getBoolean("happened_since_boot", false);
    long minNo = Math.min(noSyncWithResponseFor, noNonManualSyncFor);
    long firstSyncIn = !happenedSinceBoot || minNo >= interval ? 0 :
        interval - minNo;
    Log.e(TAG, "CONNX start isBgEnabled=" + isBgEnabled +
          " isConnected=" + isConnected +
          " isBackgroundDataOk=" + isBackgroundDataOk +
          " isScheduled=" + isScheduledNow +
          " isVerified=" + isVerified +
          " email=" + email +
          " isKnown=" + isKnown +
          " interval=" + interval +
          " happenedSinceBoot=" + happenedSinceBoot +
          " noSyncWithResponseFor=" + noSyncWithResponseFor +
          " noNonManualSyncFor=" + noNonManualSyncFor +
          " firstSyncIn=" + firstSyncIn);
    if (!isBackgroundDataOk || !isVerified || !isKnown || !isBgEnabled) {
      Log.e(TAG, "CONNX cancel isScheduled=" + isScheduledNow);
      if (isScheduledNow) {
        pendingIntent.cancel();
        context.sendBroadcast(new Intent(PROPAGATE_TO_ACTIVITY));
      }
      return;
    }
    scheduledIntent.putExtra("run", RUN_SCHEDULED);
    scheduledIntent.putExtra("email", email);
    pendingIntent = PendingIntent.getService(
        context, REQUEST_CODE_SYNC, scheduledIntent, PendingIntent.FLAG_UPDATE_CURRENT);
    // TODO: Verify that this updates the "email" in an already scheduled
    // PendingIntent, without unscheduling it.
    if (isScheduledNow)
      return;
    AlarmManager alarmManager = (AlarmManager)context.getSystemService(ALARM_SERVICE);
    Log.e(TAG, "CONNX set sync schedule firstSyncIn=" + firstSyncIn);
    alarmManager.setRepeating(
        AlarmManager.RTC_WAKEUP, now + firstSyncIn, interval,
        pendingIntent);
    context.sendBroadcast(new Intent(PROPAGATE_TO_ACTIVITY));
  }

  /**
   * Returns boolean indicating whether there is a working data connection.
   * @param context: Usually an Activity or a Service.
   */
  static boolean isConnected(Context context) {
    ConnectivityManager connectivityManager =
        (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE);
    NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
    return activeNetworkInfo != null && activeNetworkInfo.isConnected();
  }

  /** Returns null if not found. */
  static Account getAccount(Context context, String email) {
    AccountManager manager = AccountManager.get(context);
    Account[] accounts = manager.getAccountsByType("com.google");
    for (Account account : accounts) {
      if (account.name != null && account.name.equals(email))
        return account;
    }
    return null;
  }

  static final int STATUS_SYNC_OK = 0;
  static final int STATUS_STARTED_NOW = -1;
  static final int STATUS_ALREADY = -2;
  static final int STATUS_NOT_CONNECTED = 3;
  static final int STATUS_BACKGROUND_DISABLED = 4;
  static final int STATUS_TOO_EARLY = 5;
  static final int STATUS_DISABLED = 6;
  static final int STATUS_NOT_VERIFIED = 7;
  static final int STATUS_ACCOUNT_MISSING = 8;
  static final int STATUS_ACCOUNT_UNKNOWN = 9;
  static final int STATUS_RESPONSE_ERROR = 10;
  static final int STATUS_HTTP_ERROR = 11;
  static final int STATUS_NOT_YET = 12;
  static final int STATUS_JSON_PARSE_ERROR = 13;
  static final int STATUS_HTTP_PERMISSION = 14;
  static final int STATUS_UNKNOWN = 15;
  static final int STATUS_HTTP_BAD_REQUEST = 16;
  static final int STATUS_HTTP_SERVER_ERROR = 17;
  static final int STATUS_JSON_BAD_RESPONSE = 18;
  static final int STATUS_BAD_SSL_CERTIFICATE = 19;

  static String formatLastReason(int status) {
    if (status == STATUS_ACCOUNT_UNKNOWN || status == STATUS_ACCOUNT_MISSING) {
      return "the Google account was not set";
    } else if (status == STATUS_NOT_CONNECTED) {
      return "the internet connection was not working";
    } else if (status == STATUS_RESPONSE_ERROR) {
      return "there was a problem reaching the server";
    } else if (status == STATUS_HTTP_PERMISSION) {
      return "the server refused access (is the account set up?)";
    } else if (status == STATUS_HTTP_ERROR) {
      // This also happens on a bad *.appspot.com hostname.
      return "the server returned a HTTP error";
    } else if (status == STATUS_JSON_PARSE_ERROR) {
      return "the server returned a response of invalid JSON syntax";
    } else if (status == STATUS_JSON_BAD_RESPONSE) {
      return "the server returned an invalid JSON response";
    } else if (status == STATUS_HTTP_BAD_REQUEST) {
      return "the server indicated that a bad request was sent";
    } else if (status == STATUS_HTTP_SERVER_ERROR) {
      return "the server encountered an error processing the request";
    } else if (status == STATUS_BAD_SSL_CERTIFICATE) {
      return "the server had a bad SSL certificate";
    } else {
      return "an unknown error happened";
    }
  }

  /** Initiated from user action or phone settings or connectivity status change.
   * Please note that timer-scheduled sync initiated by a PendingIntent, and
   * onHandleIntent will be called directly.
   */
  static int maybeSyncNow(Context context, boolean isBackground) {
    // TODO: Get rid of this method if not used.
    ConnectivityManager connectivityManager =
        (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE);
    NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
    boolean isConnected = activeNetworkInfo != null && activeNetworkInfo.isConnected();
    boolean isBackgroundDataOk = connectivityManager.getBackgroundDataSetting();
    SharedPreferences spLastsync =
        context.getSharedPreferences("lastsync", 0);
    boolean happenedSinceBoot = spLastsync.getBoolean("happened_since_boot", false);
    long lastNonManualSyncAt = spLastsync.getLong("last_non_manual", Long.MIN_VALUE);
    int lastStatus = spLastsync.getInt("last_status", STATUS_NOT_YET);
    int lastRun = spLastsync.getInt("run", RUN_NOT_RUNNING);
    long lastSyncWithResponseAt = spLastsync.getLong("last_with_response", Long.MAX_VALUE);
    long now = System.currentTimeMillis();
    long noSyncWithResponseFor =
        (lastSyncWithResponseAt == Long.MAX_VALUE) ? Long.MAX_VALUE
        : now - lastSyncWithResponseAt;
    long noNonManualSyncFor =
        (lastNonManualSyncAt == Long.MAX_VALUE) ? Long.MAX_VALUE
        : now - lastNonManualSyncAt;
    SharedPreferences spConfig = context.getSharedPreferences("config", 0);
    int interval = spConfig.getInt("sync_interval_ms", DEFAULT_SYNC_INTERVAL_MS);
    boolean isVerified = spConfig.getBoolean("is_verified", false);
    boolean isBgEnabled = spConfig.getBoolean("is_bg_enabled", false);
    String email = spConfig.getString("email", "");
    if (email == null)
      email = "";
    Account account = email.length() == 0 ? null : getAccount(context, email);
    boolean isKnown = account != null;
    final Intent scheduledIntent = new Intent(context, SyncService.class);
    scheduledIntent.putExtra("run", RUN_SCHEDULED);
    scheduledIntent.putExtra("email", email);
    PendingIntent pendingIntent = PendingIntent.getService(
        context, REQUEST_CODE_SYNC, scheduledIntent, PendingIntent.FLAG_NO_CREATE);
    boolean isScheduledNow = pendingIntent != null;
    int run = getRun();  // TODO: Lock it!
    int newRun = isBackground ? RUN_BACKGROUND : RUN_MANUAL;
    int status = (email.length() == 0) ? STATUS_ACCOUNT_MISSING :
                 !isKnown ? STATUS_ACCOUNT_UNKNOWN :
                 (isBackground && !isVerified) ? STATUS_NOT_VERIFIED :
                 (isBackground && !isBgEnabled) ? STATUS_DISABLED :
                 !isConnected ? STATUS_NOT_CONNECTED :
                 (isBackground && !isBackgroundDataOk) ? STATUS_BACKGROUND_DISABLED :
                 // If no sync has occurred ever, the last part of this condition is false.
                 (isBackground && happenedSinceBoot && noSyncWithResponseFor <= interval) ? STATUS_TOO_EARLY :
                 // TODO: Eliminate race condition if two callers start the service at the
                 //       same time. (Maybe IntentService is not for us.)
                 run != RUN_NOT_RUNNING ? STATUS_ALREADY :
                 STATUS_STARTED_NOW;
    boolean isStartedNow = status == STATUS_STARTED_NOW;
    Log.e(TAG,  // TODO: Use Log.d everywhere.
        "CONNX maybe isBackground=" + isBackground +
            " newRun=" + newRun +
            " run=" + run +
            " isConnected=" + isConnected +
            " isBackgroundDataOk=" + isBackgroundDataOk +
            " happenedSinceBoot=" + happenedSinceBoot +
            " lastStatus=" + lastStatus +
            " lastRun=" + lastRun +
            " noSyncWithResponseFor=" + noSyncWithResponseFor +
            " noNonManualSyncFor=" + noNonManualSyncFor +
            " interval=" + interval +
            " isScheduled=" + isScheduledNow +
            " isBgEnabled=" + isBgEnabled +
            " isVerified=" + isVerified +
            " email=" + email +
            " isFound=" + isKnown +
            " status=" + status +
            " isStartedNow=" + isStartedNow);
    if (isStartedNow) {
      // If we do a manual sync now, reschedule a pending background sync so it
      // runs later.
      if (isScheduledNow) {
        AlarmManager alarmManager = (AlarmManager)context.getSystemService(ALARM_SERVICE);
        Log.e(TAG, "CONNX postpone scheduled interval=" + interval);
        alarmManager.setRepeating(
            AlarmManager.RTC_WAKEUP, System.currentTimeMillis(), interval,
            pendingIntent);
      }

      setRun(newRun);
      context.sendBroadcast(new Intent(PROPAGATE_TO_ACTIVITY));
      final Intent immediateIntent = new Intent(context, SyncService.class);
      immediateIntent.putExtra("email", email);
      immediateIntent.putExtra("run", newRun);
      context.startService(immediateIntent);
    }
    return status;
  }

  static void appendWhyNoBackgroundReasons(Context context,
                                           ArrayList<Integer> statuses) {
    ConnectivityManager connectivityManager =
        (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE);
    NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo();
    boolean isConnected = activeNetworkInfo != null && activeNetworkInfo.isConnected();
    boolean isBackgroundDataOk = connectivityManager.getBackgroundDataSetting();
    SharedPreferences spConfig = context.getSharedPreferences("config", 0);
    boolean isVerified = spConfig.getBoolean("is_verified", false);
    boolean isBgEnabled = spConfig.getBoolean("is_bg_enabled", false);
    String email = spConfig.getString("email", "");
    if (email == null)
      email = "";
    Account account = email.length() == 0 ? null : getAccount(context, email);
    boolean isKnown = account != null;

    if (!isConnected)
      statuses.add(STATUS_NOT_CONNECTED);
    if (email.length() == 0) {
      statuses.add(STATUS_ACCOUNT_MISSING);
    } else if (!isKnown) {
      statuses.add(STATUS_ACCOUNT_UNKNOWN);
    }
    if (!isVerified)
      statuses.add(STATUS_NOT_VERIFIED);
    if (!isBgEnabled)
      statuses.add(STATUS_DISABLED);
    if (!isBackgroundDataOk)
      statuses.add(STATUS_BACKGROUND_DISABLED);
  }

  static String formatWhyMessage(int status) {
    if (status == STATUS_NOT_CONNECTED) {
      return "Connect the phone to the internet (e.g. enable wifi or mobile data).";
    } else if (status == STATUS_ACCOUNT_MISSING) {
      return "Select your Google account below.";
    } else if (status == STATUS_ACCOUNT_UNKNOWN) {
      return "Select a valid Google account below.";
    } else if (status == STATUS_NOT_VERIFIED) {
      return "Run a sync manually first.";
    } else if (status == STATUS_DISABLED) {
      return "Enable background sync above.";
    } else if (status == STATUS_BACKGROUND_DISABLED) {
      return "Enable background data in the phone preferences.";
    } else {
      return "Unknown why message.";
    }
  }

  static String getWhyNoBackgroundSync(Context context) {
    ArrayList<Integer> statuses = new ArrayList<Integer>();
    appendWhyNoBackgroundReasons(context, statuses);
    StringBuilder sb = new StringBuilder();
    for (int status : statuses) {
      if (sb.length() != 0)
        sb.append('\n');
      sb.append(formatWhyMessage(status));
    }
    return sb.toString();
  }

  static String formatRun(int run) {
    if (run == RUN_NOT_RUNNING) {
      return "Not syncing right now.";
    } else if (run == RUN_MANUAL) {
      return "Manual sync in progress.";
    } else if (run == RUN_SCHEDULED) {
      return "Background scheduled sync in progress.";
    } else if (run == RUN_BACKGROUND) {
      return "Background sync in progress.";
    } else {
      return "Unknown syncing state.";
    }
  }

  static final String SYNCING_NOTHING = "Not syncing now and no background sync scheduled.";

  static String getSyncingState(Context context) {
    int run = getRun();
    // Log.e(TAG, "CONNX getSyncingState run=" + run);
    if (run != RUN_NOT_RUNNING)
      return formatRun(run);
    Intent scheduledIntent = new Intent(context, SyncService.class);
    PendingIntent pendingIntent = PendingIntent.getService(
        context, REQUEST_CODE_SYNC, scheduledIntent, PendingIntent.FLAG_NO_CREATE);
    if (pendingIntent != null) {
      return "Background sync scheduled.";
    } else {
      return SYNCING_NOTHING;
    }
  }

  /** Returns an English description saying how long ago.
   * @params ms Number of milliseconds in the past (0 is the present).
   */
  static String formatAgo(long ms) {
    if (ms < 1000) {
      return "just now";
    } else if (ms < 2000) {
      return "a second ago";
    } else if (ms < 60000) {
      return (ms / 1000) + " seconds ago";
    } else if (ms < 120000) {
      return "more than a minute ago";
    } else if (ms < 3600000) {
      return (ms / 60000) + " minutes ago";
    } else if (ms < 7200000) {
      return "more than an hour ago";
    } else if (ms < 86400000) {
      return (ms / 3600000) + " hours ago";
    } else if (ms < 172800000) {
      return "more than a day ago";
    } else {
      return (ms / 86400000) + " days ago";
    }
  }

  /** How long to sleep before a formatAgo() update. */
  static long getAgoInterval(long agoMs) {
    if (agoMs < -1000) {
      return -agoMs + 100;
    } else if (agoMs < 60000) {
      return 1000;
    } else if (agoMs < 180000) {
      return 10000;
    } else if (agoMs < 3600000) {
      return 60000;
    } else if (agoMs < 10800000) {
      return 600000;
    } else if (agoMs < 86400000) {
      return 3600000;
    } else {
      return 86400000;
    }
  }

  /** Return a definite article ("a" or "an").
   * Doesn't support numbers properly.
   */
  public static String formatArticle(String s) {
    if (s == null || s.length() == 0)
      return "";
    char c = Character.toLowerCase(s.charAt(0));
    if (c == 'a' || c == 'e' || c == 'i' || c == 'o' || c == 'u') {
      return "an ";
    } else {
      return "a ";
    }
  }

  @SuppressWarnings({"UnusedDeclaration"})
  static void logLongString(String s) {
    for (int i = 0; i < s.length(); i += 1000) {
      // Without a small sleep here not all messages arrive to the
      // Android Logcat (adb).
      try {
        Thread.sleep(100);
      } catch (InterruptedException e) {
        break;
      }
      Log.e(TAG, s.substring(i, Math.min(s.length(), i + 1000)));
    }
  }
}
